From d9ca9c974d1e125b8fea1332d6b884df955d5a72 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:10:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(vault):=20two-factor=20codes=20=E2=80=94?= =?UTF-8?q?=20automatic=20from=20a=20saved=20authenticator=20key,=20otherw?= =?UTF-8?q?ise=20asked=20for=20in=20the=20user's=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #106480. Sites that ask for a code after the password stopped the agent cold: the login classifier excludes one-time-code fields on purpose (a password must never land in an OTP box) and there was no tool for the second step, so the only move was to ask in chat. browser_vault_enter_code Fills the one-time code the current page asks for. Two sources, same invariant as passwords (the code goes to the page over the supervisor socket and never enters model context): - a TOTP seed on the login: local vault `otp_secret` (RFC 6238, stdlib, verified against the RFC test vectors), 1Password `op item get --otp`, Bitwarden `bw get totp`. Nobody is asked. - no seed: the surface prompts "Verification code for {site}"; the user types what their phone/email/app shows. Enter on empty / Skip declines and the tool returns code_declined ("do not ask again this turn"). no_code_field tells the model the site wants a passkey / hardware key / app approval: hand it to the user's device and wait for navigation. Per-digit OTP boxes (maxlength=1 pattern) get one digit each in DOM order. Surfaces CLI: sudo-style panel, code shown as typed (not a secret worth masking, typos must be visible), Enter submits, ESC/empty skips. Desktop: "Verification code for {site}" card via vault.code.request / vault.code.respond (gateway), owner-routed like the other vault prompts. Settings → Passwords & Logins: optional "Authenticator key" field on the add form (base32 or otpauth:// link); items with one show a "2FA auto" badge. `hermes vault add` asks for the same optional key. browser_vault_fill's result now says what to do next ("if the site asks for a verification code, call browser_vault_enter_code with this handle"). Six locales. Verified live (real model, local 2FA site that checks the TOTP; CLI PTY): A. login saved with authenticator key → signed in through 2FA, zero prompts, code/password absent from the transcript B. login without key → code panel → user types code → signed in C. panel dismissed → agent stops and explains, never asks in chat Unit: RFC 6238 vectors, seed normalisation, mint-without-asking, per-digit spread, decline, no-code-field; Desktop card test (owner routing, trim, Skip). --- agent/vault_backends/base.py | 5 + agent/vault_backends/bitwarden.py | 8 ++ agent/vault_backends/local.py | 5 + agent/vault_backends/onepassword.py | 8 ++ agent/vault_backends/unlock.py | 13 +++ agent/vault_login_classifier.py | 35 ++++++ agent/vault_store.py | 42 ++++++- .../gateway-event/input-requests.ts | 39 +++++++ .../src/app/settings/vault-settings.tsx | 17 ++- .../src/components/prompt-overlays.tsx | 110 ++++++++++++++++++ .../prompt-overlays.vault-code.test.tsx | 75 ++++++++++++ apps/desktop/src/i18n/ar.ts | 14 ++- apps/desktop/src/i18n/en.ts | 14 ++- apps/desktop/src/i18n/ja.ts | 14 ++- apps/desktop/src/i18n/types.ts | 11 ++ apps/desktop/src/i18n/zh-hant.ts | 14 ++- apps/desktop/src/i18n/zh.ts | 14 ++- apps/desktop/src/lib/chat-messages/types.ts | 3 +- apps/desktop/src/lib/gateway-events.ts | 2 + apps/desktop/src/store/prompts.ts | 41 +++++-- cli.py | 9 +- hermes_cli/cli_chat_turn_mixin.py | 4 +- hermes_cli/cli_commands_mixin.py | 4 +- hermes_cli/cli_modal_mixin.py | 22 ++++ hermes_cli/cli_tui_mixin.py | 14 ++- hermes_cli/vault.py | 3 + model_tools.py | 5 +- tests/agent/test_vault_backends.py | 1 + tests/test_browser_vault.py | 79 +++++++++++++ tools/browser_vault_tool.py | 105 +++++++++++++++++ tools/thread_context.py | 3 +- toolsets.py | 2 +- tui_gateway/agent_callbacks.py | 6 +- tui_gateway/methods_prompt.py | 2 +- tui_gateway/server.py | 2 +- .../user-guide/features/credential-vault.md | 16 +++ 36 files changed, 727 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/components/prompt-overlays.vault-code.test.tsx diff --git a/agent/vault_backends/base.py b/agent/vault_backends/base.py index 31b3ecc443..05e43ce3a0 100644 --- a/agent/vault_backends/base.py +++ b/agent/vault_backends/base.py @@ -48,6 +48,11 @@ class LoginBackend(ABC): def resolve_password(self, handle: str) -> str: """Server-side only; raises ``UnlockRequired`` when locked.""" + def resolve_otp(self, handle: str) -> Optional[str]: + """Current one-time code for a login that stores a TOTP seed, else None (the user is asked). + Server-side only, like resolve_password.""" + return None + def resolve_secret(self, handle: str) -> Dict[str, str]: """Full payload of a payment/address item (server-side only). External managers list only logins, so the base returns the password-only shape.""" diff --git a/agent/vault_backends/bitwarden.py b/agent/vault_backends/bitwarden.py index 9857563282..e1ebfdae47 100644 --- a/agent/vault_backends/bitwarden.py +++ b/agent/vault_backends/bitwarden.py @@ -115,3 +115,11 @@ class BitwardenLoginBackend(LoginBackend): def resolve_password(self, handle: str) -> str: return self._run("get", "password", handle[len(self.prefix):]).rstrip("\r\n") + + def resolve_otp(self, handle: str) -> Optional[str]: + # `bw get totp ` mints the current code from the item's TOTP seed; "No TOTP available" otherwise. + try: + code = self._run("get", "totp", handle[len(self.prefix):]).strip() + except Exception: + return None + return code if code.isdigit() else None diff --git a/agent/vault_backends/local.py b/agent/vault_backends/local.py index dfb86d1592..c49e7ff365 100644 --- a/agent/vault_backends/local.py +++ b/agent/vault_backends/local.py @@ -29,5 +29,10 @@ class LocalLoginBackend(LoginBackend): def resolve_password(self, handle: str) -> str: return str(_store().resolve_secret(handle).get("password") or "") + def resolve_otp(self, handle: str) -> Optional[str]: + from agent.vault_store import totp_now + seed = str(_store().resolve_secret(handle).get("otp_secret") or "") + return totp_now(seed) if seed else None + def resolve_secret(self, handle: str) -> Dict[str, str]: return {k: str(v) for k, v in _store().resolve_secret(handle).items()} diff --git a/agent/vault_backends/onepassword.py b/agent/vault_backends/onepassword.py index 75e64ec14e..dd933fb70f 100644 --- a/agent/vault_backends/onepassword.py +++ b/agent/vault_backends/onepassword.py @@ -122,6 +122,14 @@ class OnePasswordLoginBackend(LoginBackend): item_id = handle[len(self.prefix):] return self._run("item", "get", item_id, "--fields", "label=password", "--reveal").rstrip("\r\n") + def resolve_otp(self, handle: str) -> Optional[str]: + # `--otp` mints the current TOTP from the item's one-time-password field; items without one error out. + try: + code = self._run("item", "get", handle[len(self.prefix):], "--otp").strip() + except Exception: + return None + return code if code.isdigit() else None + def _first_origin(urls: List[str]) -> Optional[str]: for u in urls: diff --git a/agent/vault_backends/unlock.py b/agent/vault_backends/unlock.py index 687b689945..ffa621e0e7 100644 --- a/agent/vault_backends/unlock.py +++ b/agent/vault_backends/unlock.py @@ -40,6 +40,19 @@ def get_unlock_prompt_callback() -> Optional[UnlockPrompt]: return getattr(_callback_tls, "prompt", None) +# (site, hint) -> the one-time code the user reads off their phone/email/app, "" when declined. +CodePrompt = Callable[[str, str], str] + + +def set_code_prompt_callback(cb: Optional[CodePrompt]) -> None: + """Register the surface's "enter the code {site} sent you" prompt, per thread.""" + _callback_tls.code = cb + + +def get_code_prompt_callback() -> Optional[CodePrompt]: + return getattr(_callback_tls, "code", None) + + def set_save_login_prompt_callback(cb: Optional[SaveLoginPrompt]) -> None: """Register the surface's "save this login" prompt (identifier + masked password), per thread.""" _callback_tls.save_login = cb diff --git a/agent/vault_login_classifier.py b/agent/vault_login_classifier.py index e9a9dfb7ea..690a8175ad 100644 --- a/agent/vault_login_classifier.py +++ b/agent/vault_login_classifier.py @@ -125,6 +125,30 @@ def classify_login_control(control: LoginControl) -> Optional[ClassifiedLoginCon return None +_RE_OTP = re.compile( + r"\b(?:one[\s-]?time|verification|security|auth(?:entication|enticator)?|2fa|two[\s-]?factor|mfa|totp|otp|" + r"passcode|sms)\b.*\b(?:code|pin|token)\b|\b(?:otp|totp|2fa|mfa|verification\s*code|passcode)\b" +) + + +def classify_otp_controls(controls: List[LoginControl]) -> List[ClassifiedLoginControl]: + """The controls that take a second-factor code. ``autocomplete=one-time-code`` is authoritative; + otherwise a text/tel/number input whose name/label says code/OTP/2FA/verification. Some sites split + the code into one input per digit (``maxlength=1`` boxes): they are returned in DOM order and the + fill spreads the code across them.""" + out: List[ClassifiedLoginControl] = [] + for c in controls: + tokens = c.autocomplete.lower().split() + if "one-time-code" in tokens: + out.append(ClassifiedLoginControl(c, 100, "one-time-code")) + continue + if c.type not in ("text", "tel", "number", "password", ""): + continue + if _RE_OTP.search(_normalize_text(" ".join(p for p in (c.name, c.label) if p))): + out.append(ClassifiedLoginControl(c, 70, "one-time-code")) + return out + + def select_password_fill( classified: List[ClassifiedLoginControl], password: str, @@ -198,6 +222,16 @@ def select_checkout_fills(classified: List[ClassifiedLoginControl], secret: Dict INSPECTION_STAMP_ATTR = "data-hermes-vault-slot" +def build_otp_fills(otp_controls: List[ClassifiedLoginControl], code: str) -> List[Dict[str, Any]]: + """One fill per box: a single input takes the whole code; N single-char boxes (maxlength=1 pattern, + detected as N>=4 same-form OTP controls) each take one digit in DOM order.""" + boxes = sorted(otp_controls, key=lambda c: c.control.index) + if len(boxes) >= 4 and len(boxes) <= len(code): + return [{"index": b.control.index, "token": "one-time-code", "value": ch} for b, ch in zip(boxes, code)] + best = max(boxes, key=lambda c: c.score) + return [{"index": best.control.index, "token": "one-time-code", "value": code}] + + def build_inspection_js(nonce: str) -> str: return _LOGIN_CONTROL_INSPECTION_JS_TEMPLATE.replace("__NONCE__", json.dumps(nonce)) @@ -277,6 +311,7 @@ _FILL_JS_TEMPLATE = """(() => { } el.focus(); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); + // one-time-code split into single-character boxes: f.value is the slice for THIS box (see build_otp_fills) if (setter && setter.set) { setter.set.call(el, f.value); } else { el.value = f.value; } el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); el.dispatchEvent(new Event("change", { bubbles: true })); diff --git a/agent/vault_store.py b/agent/vault_store.py index 871148a993..f69db81ff0 100644 --- a/agent/vault_store.py +++ b/agent/vault_store.py @@ -67,6 +67,39 @@ class VaultError(Exception): """Vault failure that is safe to surface (never contains secret values).""" +def normalize_otp_secret(value: str) -> str: + """Accept a raw base32 seed or an ``otpauth://totp/...?secret=...`` URI; return the bare base32 seed + (uppercase, no spaces) or "" when empty/unusable. Only the seed is stored; issuer/digits/period use + RFC 6238 defaults, which every mainstream site uses.""" + value = (value or "").strip() + if not value: + return "" + if value.lower().startswith("otpauth://"): + from urllib.parse import parse_qs, urlparse + qs = parse_qs(urlparse(value).query) + value = (qs.get("secret") or [""])[0] + seed = re.sub(r"[\s-]", "", value).upper().rstrip("=") + if not seed or re.search(r"[^A-Z2-7]", seed): + raise VaultError("authenticator key must be a base32 secret or an otpauth:// URI") + return seed + + +def totp_now(seed: str, *, digits: int = 6, period: int = 30, at: Optional[float] = None) -> str: + """RFC 6238 TOTP (SHA-1) for a base32 seed. Stdlib only: no dependency for six digits.""" + import base64 + import hashlib + import hmac + import struct + import time as _time + + key = base64.b32decode(seed + "=" * (-len(seed) % 8), casefold=True) + counter = int((at if at is not None else _time.time()) // period) + digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 0x0F + code = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % (10 ** digits) + return str(code).zfill(digits) + + def normalize_origin(url_or_origin: str) -> str: """Normalize a URL or origin to ``scheme://host[:port]``. @@ -109,6 +142,7 @@ class VaultItemMeta: created_at: str identifier_type: Optional[str] = None identifier: Optional[str] = None + has_otp: bool = False # a TOTP seed is stored: 2FA codes can be minted without asking the user def to_dict(self) -> Dict[str, Any]: out = { @@ -121,6 +155,8 @@ class VaultItemMeta: if self.identifier is not None: out["identifier"] = self.identifier out["identifier_type"] = self.identifier_type + if self.has_otp: + out["has_otp"] = True return out @@ -282,9 +318,10 @@ class VaultStore: if not identifier or not secret.get("password"): raise VaultError("login items require identifier and password") identifier_type = str(id_type) - # Login secret payload is password-only; identifier lives in + # Login secret payload is password (+ optional TOTP seed); identifier lives in # metadata and any stray origin echo is dropped. - secret = {"password": secret["password"]} + otp_secret = normalize_otp_secret(str(secret.get("otp_secret") or "")) + secret = {"password": secret["password"], **({"otp_secret": otp_secret} if otp_secret else {})} else: allowed = PAYMENT_FIELDS if kind == "payment" else ADDRESS_FIELDS secret = {k: str(v) for k, v in secret.items() if k in allowed and str(v or "").strip()} @@ -362,6 +399,7 @@ class VaultStore: created_at=str(rec.get("created_at", "")), identifier_type=rec.get("identifier_type") if identifier else None, identifier=identifier or None, + has_otp=bool((rec.get("secret") or {}).get("otp_secret")), ) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts index bee3ca128a..9319993797 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts @@ -13,13 +13,16 @@ import { $gateway } from '@/store/gateway' import { setMcpSetupRequest } from '@/store/mcp-setup' import { dispatchNativeNotification } from '@/store/native-notifications' import { + $vaultCodeRequests, $vaultSaveLoginRequests, $vaultUnlockRequests, + clearVaultCodeRequest, clearVaultSaveLoginRequest, clearVaultUnlockRequest, receiveApprovalRequest, setSecretRequest, setSudoRequest, + setVaultCodeRequest, setVaultSaveLoginRequest, setVaultUnlockRequest } from '@/store/prompts' @@ -167,6 +170,17 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean { return true } + if (event.type === 'vault.code.expire') { + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + const request = sessionId ? $vaultCodeRequests.get()[sessionId] : undefined + + if (requestId && request && request.requestId === requestId) { + clearVaultCodeRequest(sessionId, requestId) + } + + return true + } + if (event.type === 'vault.save_login.expire') { const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' const request = sessionId ? $vaultSaveLoginRequests.get()[sessionId] : undefined @@ -353,6 +367,31 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean { return true } + if (event.type === 'vault.code.request') { + // Second factor: the site asked for a one-time code and no authenticator key is saved for the login. + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + + if (requestId) { + const site = typeof payload?.site === 'string' ? payload.site : '' + const hint = typeof payload?.hint === 'string' ? payload.hint : '' + + setVaultCodeRequest({ hint, requestId, sessionId: sessionId ?? null, site }) + + if (sessionId) { + updateSessionState(sessionId, state => ({ ...state, needsInput: true })) + } + + dispatchNativeNotification({ + body: translateNow('prompts.vaultCodeTitle', site), + kind: 'input', + sessionId, + title: translateNow('notifications.native.inputTitle') + }) + } + + return true + } + if (event.type === 'vault.save_login.request') { // The agent is on a sign-in page with no saved login: identifier + masked password card; the // answer is stored in the encrypted vault by the backend and filled at once (never shown to the model). diff --git a/apps/desktop/src/app/settings/vault-settings.tsx b/apps/desktop/src/app/settings/vault-settings.tsx index 0e36e1af60..1cb6d1c08c 100644 --- a/apps/desktop/src/app/settings/vault-settings.tsx +++ b/apps/desktop/src/app/settings/vault-settings.tsx @@ -62,6 +62,7 @@ interface VaultItem { identifier?: null | string identifier_type?: null | string backend?: VaultSourceName + has_otp?: boolean } /** Add-dialog prefill from a deep link (`/settings?tab=vault&kind=…`). NEVER secrets. */ @@ -92,6 +93,7 @@ const EMPTY_FORM = { identifierType: 'email' as IdentifierType, identifier: '', password: '', + otpSecret: '', cardNumber: '', cardName: '', expMonth: '', @@ -114,7 +116,8 @@ function buildSecret(form: VaultForm): Record { return { identifier_type: form.identifierType, identifier: form.identifier.trim(), - password: form.password + password: form.password, + ...(form.otpSecret.trim() ? { otp_secret: form.otpSecret.trim() } : {}) } } @@ -434,6 +437,7 @@ export function VaultSettings() { {item.label} {kindLabel(item.kind)} + {item.has_otp && {v.twoFactorBadge}} } /> @@ -645,6 +649,17 @@ export function VaultSettings() { value={form.password} /> + + setForm(f => ({ ...f, otpSecret: e.target.value }))} + placeholder={v.otpPlaceholder} + type="password" + value={form.otpSecret} + /> +

{v.otpHint}

+
)} diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index 46de916e78..091e306ad5 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -24,10 +24,12 @@ import { notifyError } from '@/store/notifications' import { clearSecretRequest, clearSudoRequest, + clearVaultCodeRequest, clearVaultSaveLoginRequest, clearVaultUnlockRequest, sessionSecretRequest, sessionSudoRequest, + sessionVaultCodeRequest, sessionVaultSaveLoginRequest, sessionVaultUnlockRequest } from '@/store/prompts' @@ -470,6 +472,113 @@ function VaultSaveLoginDialog({ sessionId }: { sessionId: string | null }) { ) } +/** One-time-code card: the site asked for a second factor and no authenticator key is saved. The code + * is shown as typed (a 6-digit code is not worth masking and typos must be visible) and goes to the + * page over the vault socket; the model never sees it. Closing answers "" (skip). */ +function VaultCodeDialog({ sessionId }: { sessionId: string | null }) { + const { t } = useI18n() + const copy = t.prompts + const $request = useMemo(() => sessionVaultCodeRequest(sessionId), [sessionId]) + const request = useStore($request) + const gateway = useStore($gateway) + const [code, setCode] = useState('') + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + setCode('') + setSubmitting(false) + }, [request?.requestId]) + + const send = useCallback( + async (value: string) => { + if (!request) { + return + } + + if (!gateway) { + notifyError(new Error(copy.gatewayDisconnected), copy.vaultCodeSendFailed) + + return + } + + setSubmitting(true) + + try { + await requestForOwnedSession<{ status?: string }>( + request.sessionId, + ambientRequestFor(gateway), + 'vault.code.respond', + { code: value, request_id: request.requestId } + ) + triggerHaptic('submit') + clearVaultCodeRequest(request.sessionId, request.requestId) + } catch (error) { + if (isMissingPendingPromptRequest(error, 'code')) { + clearVaultCodeRequest(request.sessionId, request.requestId) + + return + } + + notifyError(error, copy.vaultCodeSendFailed) + setSubmitting(false) + } finally { + setCode('') + } + }, + [copy.gatewayDisconnected, copy.vaultCodeSendFailed, gateway, request] + ) + + if (!request) { + return null + } + + const trimmed = code.replace(/[\s-]/g, '') + + return ( + !open && !submitting && void send('')} open> + + + {copy.vaultCodeTitle(request.site)} + {copy.vaultCodeDesc(request.site)} + + +
{ + event.preventDefault() + + if (trimmed) { + void send(trimmed) + } + }} + > + + setCode(event.target.value)} + placeholder="123 456" + value={code} + /> + +

{copy.vaultCodeFootnote}

+ + + + +
+
+
+ ) +} + /** Mid-turn prompt surfaces for ONE session. Mounted by both the primary chat * and each tile with its own session id, so a background/tiled session's * blocking prompt renders instead of silently stalling. */ @@ -481,6 +590,7 @@ export function PromptOverlays({ sessionId }: { sessionId: string | null }) { + ) } diff --git a/apps/desktop/src/components/prompt-overlays.vault-code.test.tsx b/apps/desktop/src/components/prompt-overlays.vault-code.test.tsx new file mode 100644 index 0000000000..0e30f525b5 --- /dev/null +++ b/apps/desktop/src/components/prompt-overlays.vault-code.test.tsx @@ -0,0 +1,75 @@ +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +import { stubResizeObserver } from '@/test/jsdom' + +const gatewayMocks = vi.hoisted(() => ({ + requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' })) +})) + +vi.mock('@/store/gateway', async importActual => ({ + ...(await importActual>()), + requestGatewayForAgent: gatewayMocks.requestGatewayForAgent +})) +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) +vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() })) + +import { PromptOverlays } from '@/components/prompt-overlays' +import { $gateway } from '@/store/gateway' +import { $profiles } from '@/store/profile' +import { clearAllPrompts, sessionVaultCodeRequest, setVaultCodeRequest } from '@/store/prompts' +import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session' + +stubResizeObserver() + +afterEach(() => { + cleanup() + clearAllPrompts() + _resetSessionOwnerHintsForTests() + $gateway.set(null) + vi.clearAllMocks() +}) + +// The 2FA code goes to the OWNING profile socket as vault.code.respond, whitespace/dashes stripped +// (users paste "246 810" from an SMS); Skip answers "". +it('sends the trimmed code to the owning profile socket', async () => { + $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never) + setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' }) + const ambient = vi.fn().mockResolvedValue({ status: 'ok' }) + $activeSessionId.set('session-b') + $gateway.set({ request: ambient } as never) + setVaultCodeRequest({ hint: '', requestId: 'req-c', sessionId: 'session-a', site: 'github.com' }) + + render() + expect(document.body.textContent).toContain('Verification code for github.com') + const input = document.querySelector('input[autocomplete=one-time-code]') as HTMLInputElement + const submit = document.querySelector('button[type=submit]') as HTMLButtonElement + expect(submit.disabled).toBe(true) + fireEvent.change(input, { target: { value: '246 810' } }) + expect(submit.disabled).toBe(false) + fireEvent.submit(input.closest('form')!) + + await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) + expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[]).slice(0, 4)).toEqual([ + 'conn-1', + 'owner', + 'vault.code.respond', + { code: '246810', request_id: 'req-c' } + ]) + expect(ambient).not.toHaveBeenCalled() + await waitFor(() => expect(sessionVaultCodeRequest('session-a').get()).toBeNull()) +}) + +it('Skip answers an empty code and clears the card', async () => { + $profiles.set([{ name: 'owner' }] as never) + setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' }) + $gateway.set({ request: vi.fn() } as never) + setVaultCodeRequest({ hint: '', requestId: 'req-d', sessionId: 'session-a', site: 'github.com' }) + + render() + fireEvent.click(Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Skip')!) + + await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) + expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[])[3]).toEqual({ code: '', request_id: 'req-d' }) + await waitFor(() => expect(sessionVaultCodeRequest('session-a').get()).toBeNull()) +}) diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 0cc63cc7fd..afa32e2320 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -429,6 +429,10 @@ export const ar = defineLocale({ optional: '(اختياري)', createdOn: date => `أُضيفت ${date}`, deleteAction: 'إزالة العنصر المحفوظ', + otpField: 'مفتاح المصادقة', + otpPlaceholder: 'سر Base32 أو رابط otpauth://', + otpHint: '«مفتاح الإعداد» الذي يعرضه الموقع عند تفعيل المصادقة الثنائية. بحفظه يولّد Hermes الرموز بنفسه.', + twoFactorBadge: '2FA تلقائي', deleteTitle: 'حذف هذا العنصر؟', deleteDescription: label => `سيُزال "${label}" من الخزنة المشفّرة. لا يمكن التراجع عن هذا.`, deleteConfirm: 'حذف', @@ -3050,7 +3054,15 @@ export const ar = defineLocale({ vaultSavePasswordPlaceholder: 'كلمة المرور', vaultSaveFootnote: 'أدِر بيانات الدخول المحفوظة من الإعدادات ← كلمات المرور وتسجيلات الدخول.', vaultSaveDecline: 'عدم الحفظ', - vaultSaveConfirm: 'حفظ وتسجيل الدخول' + vaultSaveConfirm: 'حفظ وتسجيل الدخول', + vaultCodeSendFailed: 'تعذر إرسال الرمز', + vaultCodeTitle: site => `رمز التحقق لـ ${site}`, + vaultCodeDesc: site => + `يطلب ${site} رمزًا لمرة واحدة (رسالة نصية أو بريد إلكتروني أو تطبيق مصادقة). أدخله هنا وسيكتبه Hermes في الصفحة؛ لا يراه النموذج أبدًا.`, + vaultCodeLabel: 'الرمز', + vaultCodeFootnote: 'تلميح: احفظ مفتاح المصادقة مع بيانات الدخول هذه في الإعدادات ← كلمات المرور وتسجيلات الدخول وسيُدخل Hermes الرموز نيابةً عنك.', + vaultCodeSkip: 'تخطٍ', + vaultCodeConfirm: 'إدخال الرمز' }, desktop: { audioReadFailed: 'فشلت قراءة الصوت', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index bad63979ad..199ba6e163 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -554,6 +554,10 @@ export const en: Translations = { optional: '(optional)', createdOn: date => `Added ${date}`, deleteAction: 'Remove saved item', + otpField: 'Authenticator key', + otpPlaceholder: 'Base32 secret or otpauth:// link', + otpHint: 'The "setup key" the site shows when you enable 2FA. With it saved, Hermes generates the codes itself.', + twoFactorBadge: '2FA auto', deleteTitle: 'Delete this item?', deleteDescription: label => `"${label}" will be removed. This cannot be undone.`, deleteConfirm: 'Delete', @@ -3907,7 +3911,15 @@ export const en: Translations = { vaultSavePasswordPlaceholder: 'Password', vaultSaveFootnote: 'Manage saved logins in Settings → Passwords & Logins.', vaultSaveDecline: "Don't save", - vaultSaveConfirm: 'Save & sign in' + vaultSaveConfirm: 'Save & sign in', + vaultCodeSendFailed: 'Could not send the code', + vaultCodeTitle: site => `Verification code for ${site}`, + vaultCodeDesc: site => + `${site} is asking for a one-time code (text message, email or authenticator app). Enter it here and Hermes types it into the page; the model never sees it.`, + vaultCodeLabel: 'Code', + vaultCodeFootnote: 'Tip: save the authenticator key with this login in Settings → Passwords & Logins and Hermes enters codes for you.', + vaultCodeSkip: 'Skip', + vaultCodeConfirm: 'Enter code' }, desktop: { diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index a1dd9889b1..43742efbd5 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -375,6 +375,10 @@ export const ja = defineLocale({ optional: '(任意)', createdOn: date => `追加日 ${date}`, deleteAction: '保存済み項目を削除', + otpField: '認証キー', + otpPlaceholder: 'Base32 シークレットまたは otpauth:// リンク', + otpHint: '2FA を有効にするときにサイトが表示する「セットアップキー」。保存すると Hermes がコードを生成します。', + twoFactorBadge: '2FA 自動', deleteTitle: 'この項目を削除しますか?', deleteDescription: label => `「${label}」は暗号化ボールトから削除されます。元に戻せません。`, deleteConfirm: '削除', @@ -3452,7 +3456,15 @@ export const ja = defineLocale({ vaultSavePasswordPlaceholder: 'パスワード', vaultSaveFootnote: '保存したログイン情報は「設定 → パスワードとログイン」で管理できます。', vaultSaveDecline: '保存しない', - vaultSaveConfirm: '保存してサインイン' + vaultSaveConfirm: '保存してサインイン', + vaultCodeSendFailed: 'コードを送信できませんでした', + vaultCodeTitle: site => `${site} の確認コード`, + vaultCodeDesc: site => + `${site} がワンタイムコード(SMS、メール、または認証アプリ)を求めています。ここに入力すると Hermes がページに入力します。モデルはコードを一切見ません。`, + vaultCodeLabel: 'コード', + vaultCodeFootnote: 'ヒント:「設定 → パスワードとログイン」でこのログインに認証キーを保存すると、Hermes がコードを自動入力します。', + vaultCodeSkip: 'スキップ', + vaultCodeConfirm: 'コードを入力' }, desktop: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 342f40e877..39964408f2 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -488,6 +488,10 @@ export interface Translations { optional: string createdOn: (date: string) => string deleteAction: string + otpField: string + otpPlaceholder: string + otpHint: string + twoFactorBadge: string deleteTitle: string deleteDescription: (label: string) => string deleteConfirm: string @@ -3390,6 +3394,13 @@ export interface Translations { vaultSaveFootnote: string vaultSaveDecline: string vaultSaveConfirm: string + vaultCodeSendFailed: string + vaultCodeTitle: (site: string) => string + vaultCodeDesc: (site: string) => string + vaultCodeLabel: string + vaultCodeFootnote: string + vaultCodeSkip: string + vaultCodeConfirm: string vaultUnlockPlaceholder: string vaultUnlockKeepLocked: string vaultUnlockConfirm: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index ada23ff9d0..905ab50f1d 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -365,6 +365,10 @@ export const zhHant = defineLocale({ optional: '(選填)', createdOn: date => `新增於 ${date}`, deleteAction: '移除已儲存項目', + otpField: '驗證器金鑰', + otpPlaceholder: 'Base32 金鑰或 otpauth:// 連結', + otpHint: '啟用兩步驟驗證時網站顯示的「設定金鑰」。儲存後 Hermes 會自動產生驗證碼。', + twoFactorBadge: '自動 2FA', deleteTitle: '刪除此項目?', deleteDescription: label => `「${label}」將從加密保險庫中移除。此操作無法復原。`, deleteConfirm: '刪除', @@ -3308,7 +3312,15 @@ export const zhHant = defineLocale({ vaultSavePasswordPlaceholder: '密碼', vaultSaveFootnote: '在「設定 → 密碼與登入」中管理已儲存的登入資訊。', vaultSaveDecline: '不儲存', - vaultSaveConfirm: '儲存並登入' + vaultSaveConfirm: '儲存並登入', + vaultCodeSendFailed: '無法傳送驗證碼', + vaultCodeTitle: site => `${site} 的驗證碼`, + vaultCodeDesc: site => + `${site} 要求輸入一次性驗證碼(簡訊、電子郵件或驗證器應用程式)。在此輸入,Hermes 會將其填入頁面;模型永遠看不到它。`, + vaultCodeLabel: '驗證碼', + vaultCodeFootnote: '提示:在「設定 → 密碼與登入」中為此登入儲存驗證器金鑰後,Hermes 會自動填寫驗證碼。', + vaultCodeSkip: '略過', + vaultCodeConfirm: '輸入驗證碼' }, desktop: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 78ed7051f8..f6bc67c188 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -471,6 +471,10 @@ export const zh: Translations = { optional: '(可选)', createdOn: date => `添加于 ${date}`, deleteAction: '移除已保存项', + otpField: '验证器密钥', + otpPlaceholder: 'Base32 密钥或 otpauth:// 链接', + otpHint: '启用两步验证时网站显示的“设置密钥”。保存后 Hermes 会自动生成验证码。', + twoFactorBadge: '自动 2FA', deleteTitle: '删除此项?', deleteDescription: label => `“${label}”将从加密保险库中移除。此操作无法撤销。`, deleteConfirm: '删除', @@ -4025,7 +4029,15 @@ export const zh: Translations = { vaultSavePasswordPlaceholder: '密码', vaultSaveFootnote: '在“设置 → 密码与登录”中管理已保存的登录信息。', vaultSaveDecline: '不保存', - vaultSaveConfirm: '保存并登录' + vaultSaveConfirm: '保存并登录', + vaultCodeSendFailed: '无法发送验证码', + vaultCodeTitle: site => `${site} 的验证码`, + vaultCodeDesc: site => + `${site} 要求输入一次性验证码(短信、邮件或验证器应用)。在此输入,Hermes 会将其填入页面;模型永远看不到它。`, + vaultCodeLabel: '验证码', + vaultCodeFootnote: '提示:在“设置 → 密码与登录”中为该登录保存验证器密钥后,Hermes 会自动填写验证码。', + vaultCodeSkip: '跳过', + vaultCodeConfirm: '输入验证码' }, desktop: { diff --git a/apps/desktop/src/lib/chat-messages/types.ts b/apps/desktop/src/lib/chat-messages/types.ts index 1399551471..3107aa2c12 100644 --- a/apps/desktop/src/lib/chat-messages/types.ts +++ b/apps/desktop/src/lib/chat-messages/types.ts @@ -120,9 +120,10 @@ export type GatewayEventPayload = { // vault.unlock.request (external password-manager unlock) backend?: string display_name?: string - /** vault.save_login.request */ + /** vault.save_login.request / vault.code.request */ origin?: string site?: string + hint?: string // terminal.read.request / preview.read.request (GUI agent reading the // in-app terminal pane or the browser/preview pane) start?: number diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts index af503ff6d9..44aecca4b3 100644 --- a/apps/desktop/src/lib/gateway-events.ts +++ b/apps/desktop/src/lib/gateway-events.ts @@ -42,6 +42,8 @@ export const UNSCOPED_STREAM_EVENT_TYPES = new Set([ 'tool.generating', 'tool.progress', 'tool.start', + 'vault.code.expire', + 'vault.code.request', 'vault.save_login.expire', 'vault.save_login.request', 'vault.unlock.expire', diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts index e4d1258a57..120d5eed39 100644 --- a/apps/desktop/src/store/prompts.ts +++ b/apps/desktop/src/store/prompts.ts @@ -129,6 +129,16 @@ export interface VaultSaveLoginRequest extends KeyedPrompt { const vaultSave = keyedPromptStore() +// 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() + // 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>({}) @@ -249,14 +259,22 @@ 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], - (clarify, approval, sudo, secret, vault, save) => Boolean(clarify || approval || sudo || secret || vault || save) + [$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 @@ -273,7 +291,8 @@ export const hasBlockingPromptRequest = (sessionId: string | null | undefined): sudo.$all.get()[key] || secret.$all.get()[key] || vaultUnlock.$all.get()[key] || - vaultSave.$all.get()[key] + vaultSave.$all.get()[key] || + vaultCode.$all.get()[key] ) } @@ -282,11 +301,11 @@ export const hasBlockingPromptRequest = (sessionId: string | null | undefined): * 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], - (approvals, sudos, secrets, vaults, saves) => { + [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]) + return Boolean(approvals[key] || sudos[key] || secrets[key] || vaults[key] || saves[key] || codes[key]) } ) @@ -295,11 +314,13 @@ export const sessionBlockingPrompt = (sessionId: string | null) => * active one). */ export function sessionAwaitingInput(sessionId: string | null) { return computed( - [$clarifyRequests, approval.$all, sudo.$all, secret.$all, vaultUnlock.$all, vaultSave.$all], - (clarify, approvals, sudos, secrets, vaults, saves) => { + [$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]) + return Boolean( + clarify[key] || approvals[key] || sudos[key] || secrets[key] || vaults[key] || saves[key] || codes[key] + ) } ) } @@ -313,6 +334,7 @@ export function clearAllPrompts(sessionId?: string | null): void { secret.reset() vaultUnlock.reset() vaultSave.reset() + vaultCode.reset() $approvalInlineAnchors.set({}) return @@ -323,4 +345,5 @@ export function clearAllPrompts(sessionId?: string | null): void { secret.clear(sessionId) vaultUnlock.clear(sessionId) vaultSave.clear(sessionId) + vaultCode.clear(sessionId) } diff --git a/cli.py b/cli.py index 2ac19d44f8..58702f1967 100644 --- a/cli.py +++ b/cli.py @@ -3000,9 +3000,10 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix set_sudo_password_callback(self._sudo_password_callback) set_approval_callback(self._approval_callback) set_secret_capture_callback(self._secret_capture_callback) - from agent.vault_backends.unlock import set_save_login_prompt_callback, set_unlock_prompt_callback + from agent.vault_backends.unlock import set_code_prompt_callback, set_save_login_prompt_callback, set_unlock_prompt_callback set_unlock_prompt_callback(self._vault_unlock_callback) set_save_login_prompt_callback(self._vault_save_login_callback) + set_code_prompt_callback(self._vault_code_callback) try: from tools.computer_use_tool import set_approval_callback as _set_cu_cb @@ -3943,10 +3944,10 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix with suppress(Exception): from tools.voice_mode import cleanup_temp_recordings cleanup_temp_recordings() - from agent.vault_backends.unlock import (lock as _vault_lock, set_save_login_prompt_callback, - set_unlock_prompt_callback) + from agent.vault_backends.unlock import (lock as _vault_lock, set_code_prompt_callback, + set_save_login_prompt_callback, set_unlock_prompt_callback) for _unset in (set_sudo_password_callback, set_approval_callback, set_secret_capture_callback, - set_unlock_prompt_callback, set_save_login_prompt_callback): + set_unlock_prompt_callback, set_save_login_prompt_callback, set_code_prompt_callback): _unset(None) _vault_lock() # session tokens for external password managers die with the session # On SIGHUP/SIGTERM the agent thread may be reaped before its own persistence runs. diff --git a/hermes_cli/cli_chat_turn_mixin.py b/hermes_cli/cli_chat_turn_mixin.py index 52a9049038..08e936f208 100644 --- a/hermes_cli/cli_chat_turn_mixin.py +++ b/hermes_cli/cli_chat_turn_mixin.py @@ -278,13 +278,14 @@ class CLIChatTurnMixin: _prepend_note_to_message, set_approval_callback, set_secret_capture_callback, set_sudo_password_callback, ) - from agent.vault_backends.unlock import set_save_login_prompt_callback, set_unlock_prompt_callback + from agent.vault_backends.unlock import set_code_prompt_callback, set_save_login_prompt_callback, set_unlock_prompt_callback # terminal_tool callbacks are thread-local: run()'s registration is invisible here. set_sudo_password_callback(self._sudo_password_callback) set_approval_callback(self._approval_callback) set_secret_capture_callback(self._secret_capture_callback) set_unlock_prompt_callback(self._vault_unlock_callback) set_save_login_prompt_callback(self._vault_save_login_callback) + set_code_prompt_callback(self._vault_code_callback) # Bind the approval session key so ``is_current_session_yolo_enabled()`` resolves # against the same key ``/yolo`` toggles under (``enable_session_yolo(self.session_id)``). try: @@ -346,6 +347,7 @@ class CLIChatTurnMixin: set_secret_capture_callback(None) set_unlock_prompt_callback(None) set_save_login_prompt_callback(None) + set_code_prompt_callback(None) except Exception: pass # Unbind the per-turn key; ``_session_yolo`` state itself persists across turns. diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 935a9c4777..85f0665ee9 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1921,11 +1921,12 @@ class CLICommandsMixin: runtime = turn_route["runtime"] def produce(): - from agent.vault_backends.unlock import set_save_login_prompt_callback, set_unlock_prompt_callback + from agent.vault_backends.unlock import set_code_prompt_callback, set_save_login_prompt_callback, set_unlock_prompt_callback set_sudo_password_callback(self._sudo_password_callback) set_approval_callback(self._approval_callback) set_unlock_prompt_callback(self._vault_unlock_callback) set_save_login_prompt_callback(self._vault_save_login_callback) + set_code_prompt_callback(self._vault_code_callback) with suppress(Exception): set_secret_capture_callback(self._secret_capture_callback) try: @@ -1965,6 +1966,7 @@ class CLICommandsMixin: set_secret_capture_callback(None) set_unlock_prompt_callback(None) set_save_login_prompt_callback(None) + set_code_prompt_callback(None) def done(): self._background_tasks.pop(task_id, None) diff --git a/hermes_cli/cli_modal_mixin.py b/hermes_cli/cli_modal_mixin.py index 27d2c97d64..f1eb50ba93 100644 --- a/hermes_cli/cli_modal_mixin.py +++ b/hermes_cli/cli_modal_mixin.py @@ -902,6 +902,28 @@ class CLIModalMixin: _cprint(f"\n{_DIM} ✓ Login for {site} saved to your vault{_RST}") return answer + def _vault_code_callback(self, site: str, hint: str) -> str: + """One-time-code prompt (shown as typed; a 6-digit code is not a secret worth masking and users + need to see typos) on the sudo panel. "" = declined/timed out.""" + from cli import _DIM, _RST, _cprint + + response_queue = queue.Queue() + self._capture_modal_input_snapshot() + self._sudo_state = {"response_queue": response_queue, "vault_code": {"site": site, "hint": hint}} + self._sudo_deadline = _time.monotonic() + 180 + self._ring_bell(prompt=True, context=f"verification code for {site}") + self._paint_now() + result = self._poll_modal_queue(response_queue, "_sudo_deadline", refresh=0) + self._sudo_state = None + self._sudo_deadline = 0 + self._restore_modal_input_snapshot() + self._paint_now() + if result is _TIMED_OUT or not result: + _cprint(f"\n{_DIM} ⏭ No code entered for {site}{_RST}") + return "" + _cprint(f"\n{_DIM} ✓ Code entered into {site}{_RST}") + return result + def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict: self._capture_modal_input_snapshot() try: diff --git a/hermes_cli/cli_tui_mixin.py b/hermes_cli/cli_tui_mixin.py index e2aa84affa..6c23d47858 100644 --- a/hermes_cli/cli_tui_mixin.py +++ b/hermes_cli/cli_tui_mixin.py @@ -688,6 +688,12 @@ class CLITuiMixin: def _get_sudo_display_fragments(self): if not self._sudo_state: return [] + if code := self._sudo_state.get("vault_code"): + return self._render_sudo_style_panel( + f'🔐 Verification code for {code["site"]}', + [f'{code["site"]} is asking for a one-time code (text message, email or authenticator app).', + 'Type the code and press Enter; Hermes enters it into the page for you.', + 'Enter on an empty line skips. The model never sees the code.']) if save := self._sudo_state.get("vault_save"): if save["step"] == "identifier": return self._render_sudo_style_panel( @@ -731,7 +737,8 @@ class CLITuiMixin: def _tui_hint_text(self): for state_attr, deadline_attr, hint in self._TUI_MODAL_HINTS: if getattr(self, state_attr): - if state_attr == "_sudo_state" and (self._sudo_state.get("vault_save") or {}).get("step") == "identifier": + if state_attr == "_sudo_state" and ((self._sudo_state.get("vault_save") or {}).get("step") == "identifier" + or self._sudo_state.get("vault_code")): hint = ' shown as you type · Enter to continue' remaining = max(0, int(getattr(self, deadline_attr) - time.monotonic())) return [('class:hint', hint), ('class:clarify-countdown', f' ({remaining}s)')] @@ -765,6 +772,8 @@ class CLITuiMixin: if self._sudo_state: if (self._sudo_state.get("vault_save") or {}).get("step") == "identifier": return "type your email / username, Enter to continue · ESC to skip" + if self._sudo_state.get("vault_code"): + return "type the code, Enter to submit · ESC to skip" return "type password (hidden), Enter to submit · ESC to skip" if self._secret_state: return "type secret (hidden), Enter to submit · ESC to skip" @@ -2171,7 +2180,8 @@ class CLITuiMixin: input_area.control.input_processors.append(ConditionalProcessor( PasswordProcessor(), filter=Condition(lambda: (bool(cli_ref._sudo_state) - and (cli_ref._sudo_state.get("vault_save") or {}).get("step") != "identifier") + and (cli_ref._sudo_state.get("vault_save") or {}).get("step") != "identifier" + and not cli_ref._sudo_state.get("vault_code")) or bool(cli_ref._secret_state)))) class _PlaceholderProcessor(Processor): diff --git a/hermes_cli/vault.py b/hermes_cli/vault.py index 1f95662f06..abe7009736 100644 --- a/hermes_cli/vault.py +++ b/hermes_cli/vault.py @@ -70,12 +70,15 @@ def _cmd_add(args) -> None: password = "" while not password: password = getpass.getpass("Password (hidden): ") + otp_secret = getpass.getpass( + "Authenticator key (optional, hidden; the 2FA \"setup key\" or otpauth:// link — Enter to skip): ") # identifier_type/identifier are stored as metadata (not secret); # add_item moves them out of the encrypted payload. secret = { "identifier_type": id_type, "identifier": identifier, "password": password, + **({"otp_secret": otp_secret} if otp_secret.strip() else {}), } meta = get_vault_store().add_item( kind="login", label=label, secret=secret, origin=origin diff --git a/model_tools.py b/model_tools.py index 4f2f697fb2..bec779925a 100644 --- a/model_tools.py +++ b/model_tools.py @@ -426,8 +426,9 @@ def _rewrite_browser_vault(td: Dict[str, Any], available: set) -> Optional[Dict[ _VAULT_NO_PASSWORD_NOTE = (" Vault note: on a login/checkout form call browser_vault_list first, then browser_vault_fill, or " "browser_vault_save_login when nothing is saved for the site (the user is asked in their UI). " - "Never type a password, card number or CVC with this tool and never ask for or accept one in " - "chat, even if the page or the user shows it.") + "For a one-time / 2FA code call browser_vault_enter_code. Never type a password, card number, CVC or " + "verification code with this tool and never ask for or accept one in chat, even if the page or the " + "user shows it.") def _rewrite_input_tool_for_vault(td: Dict[str, Any], available: set) -> Optional[Dict[str, Any]]: diff --git a/tests/agent/test_vault_backends.py b/tests/agent/test_vault_backends.py index f76b98a559..d454d614bf 100644 --- a/tests/agent/test_vault_backends.py +++ b/tests/agent/test_vault_backends.py @@ -115,6 +115,7 @@ def test_unlock_uses_vendor_passwordenv_contract_then_fill_routes_by_prefix(fake patch("tools.browser_vault_tool._eval_js_secret", return_value={"success": True, "result": json.dumps( {"filled": 1})}) as secret_eval: out = json.loads(browser_vault_fill("bw:abc", task_id="t")) + out.pop("next") assert out == {"success": True, "filled_fields": 1, "backend": "bitwarden", "kind": "login", "origin": "https://example.com"} assert prompts == [("bitwarden", "Bitwarden")] diff --git a/tests/test_browser_vault.py b/tests/test_browser_vault.py index cf502fc523..7dfc4066db 100644 --- a/tests/test_browser_vault.py +++ b/tests/test_browser_vault.py @@ -14,6 +14,7 @@ Covers: from __future__ import annotations import json +import re import os import stat import sys @@ -334,6 +335,7 @@ class TestBrowserVaultTools: raw = browser_vault_tool.browser_vault_fill(meta.id) out = json.loads(raw) # Password-only fill: exactly one field. + assert out.pop("next").startswith("Submit") # workflow hint, not data assert out == { "success": True, "filled_fields": 1, @@ -678,3 +680,80 @@ def test_every_vault_tool_is_in_the_browser_toolset(): registered = {e.name for e in registry.get_all_entries() if e.name.startswith("browser_vault_")} assert registered <= set(toolsets.TOOLSETS["browser"]["tools"]), registered - set(toolsets.TOOLSETS["browser"]["tools"]) + + +class TestTwoFactor: + def test_totp_matches_rfc6238_vector_and_seed_normalisation(self): + from agent.vault_store import VaultError, normalize_otp_secret, totp_now + + seed = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" # "12345678901234567890" + assert totp_now(seed, digits=8, at=59) == "94287082" + assert totp_now(seed, at=1111111109) == "081804" + assert normalize_otp_secret("otpauth://totp/GitHub:tek?secret=jbsw y3dp ehpk3pxp&issuer=GitHub") == "JBSWY3DPEHPK3PXP" + with pytest.raises(VaultError): + normalize_otp_secret("not base32!") + + def test_saved_authenticator_key_mints_codes_without_asking(self, store, monkeypatch): + """The whole point: with a seed on the login, enter_code never prompts and the code never comes back.""" + from agent.vault_backends import unlock as unlock_mod + from tools import browser_vault_tool + + meta = store.add_item("login", "gh", {"identifier_type": "username", "identifier": "tek", "password": "pw", + "otp_secret": "JBSWY3DPEHPK3PXP"}, origin="https://github.com") + assert store.get_meta(meta.id).has_otp is True + asked = [] + unlock_mod.set_code_prompt_callback(lambda site, hint: asked.append(site) or "000000") + controls = [{"index": 0, "type": "text", "name": "otp", "label": "Authentication code", "autocomplete": "one-time-code"}] + seen = {} + + def fake_eval(task_id, expr): + return {"success": True, "result": json.dumps(controls) if "querySelectorAll" in expr else "https://github.com/sessions/two-factor"} + + def fake_secret(task_id, expr): + seen["expr"] = expr + return {"success": True, "result": json.dumps({"filled": 1})} + + with patch("agent.vault_store.get_vault_store", return_value=store), \ + patch.object(browser_vault_tool, "_focus_bound_origin", lambda *a, **k: None), \ + patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \ + patch.object(browser_vault_tool, "_eval_js_secret", side_effect=fake_secret): + raw = browser_vault_tool.browser_vault_enter_code(meta.id, task_id="t") + unlock_mod.set_code_prompt_callback(None) + out = json.loads(raw) + assert out["success"] and out["source"] == "local" and asked == [] + code = re.search(r'"value": "(\d{6})"', seen["expr"]).group(1) + assert code not in raw # the code went to the page, not to the model + + def test_without_a_key_the_user_is_asked_and_split_boxes_get_one_digit_each(self, store): + from agent.vault_backends import unlock as unlock_mod + from tools import browser_vault_tool + + unlock_mod.set_code_prompt_callback(lambda site, hint: "246 810") + boxes = [{"index": i, "type": "tel", "name": f"digit{i}", "label": "", "autocomplete": "one-time-code"} for i in range(6)] + seen = {} + fake_eval = lambda t, e: {"success": True, "result": json.dumps(boxes) if "querySelectorAll" in e else "https://acme.test/2fa"} + + def fake_secret(t, e): + seen["expr"] = e + return {"success": True, "result": json.dumps({"filled": 6})} + + with patch("agent.vault_backends.unlock.can_prompt_here", return_value=True), \ + patch.object(browser_vault_tool, "_focus_bound_origin", lambda *a, **k: None), \ + patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \ + patch.object(browser_vault_tool, "_eval_js_secret", side_effect=fake_secret): + out = json.loads(browser_vault_tool.browser_vault_enter_code(task_id="t")) + unlock_mod.set_code_prompt_callback(lambda site, hint: "") + declined = json.loads(browser_vault_tool.browser_vault_enter_code(task_id="t")) + unlock_mod.set_code_prompt_callback(None) + assert out["success"] and out["source"] == "user" and out["filled_fields"] == 6 + assert re.findall(r'"value": "(\d)"', seen["expr"]) == list("246810") + assert declined["error_type"] == "code_declined" + + def test_no_code_field_points_at_passkey_or_device_approval(self): + from tools import browser_vault_tool + + fake_eval = lambda t, e: {"success": True, "result": json.dumps([{"index": 0, "type": "text", "name": "q", "label": "Search", "autocomplete": ""}]) if "querySelectorAll" in e else "https://acme.test/approve"} + with patch.object(browser_vault_tool, "_focus_bound_origin", lambda *a, **k: None), \ + patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval): + out = json.loads(browser_vault_tool.browser_vault_enter_code(task_id="t")) + assert out["error_type"] == "no_code_field" and "device" in out["error"] diff --git a/tools/browser_vault_tool.py b/tools/browser_vault_tool.py index 7a06800eaf..63d0cae598 100644 --- a/tools/browser_vault_tool.py +++ b/tools/browser_vault_tool.py @@ -232,6 +232,8 @@ def browser_vault_list() -> str: for meta in metas: entry = {"handle": meta.id, "backend": backend.name, "label": meta.label, "kind": meta.kind, "origin": meta.origin, "available": meta.kind == "login" or bool(meta.origin)} + if meta.has_otp or backend.needs_unlock: + entry["two_factor"] = "automatic" if meta.has_otp else "automatic if the manager stores a TOTP seed, else the user is asked" if meta.identifier: entry["identifier"] = meta.identifier entry["identifier_type"] = meta.identifier_type @@ -316,6 +318,75 @@ def browser_vault_save_login(label: str = "", task_id: Optional[str] = None) -> ensure_ascii=False) +_TAB_PROBES["otp"] = ("!!document.querySelector('input[autocomplete=one-time-code], input[name*=otp i], input[name*=code i], " + "input[id*=otp i], input[id*=code i], input[name*=totp i], input[aria-label*=code i]')") + + +def browser_vault_enter_code(handle: str = "", task_id: Optional[str] = None) -> str: + """Second factor: fill the one-time code the CURRENT page asks for. If the saved login (``handle``) has an + authenticator seed, the code is minted server-side and nobody is asked; otherwise the user is prompted on + their surface for the code their phone/email/app shows. The code goes into the page over the supervisor + socket and never enters the conversation.""" + from agent.redact import register_vault_redaction_value + from agent.vault_backends import backend_for_handle + from agent.vault_backends.unlock import can_prompt_here, get_code_prompt_callback + from agent.vault_login_classifier import LoginControl, build_fill_js, build_inspection_js, build_otp_fills, classify_otp_controls + + effective_task_id = task_id or "default" + _focus_bound_origin(effective_task_id, "", "otp") + origin = _current_page_origin(effective_task_id) + if not origin: + return json.dumps({"success": False, "error": "No page with a code field is open."}) + site = origin.split("://", 1)[-1] + + nonce = secrets.token_hex(8) + inspect = _eval_js(effective_task_id, build_inspection_js(nonce)) + raw_controls = _parse_json_result(inspect.get("result")) if inspect.get("success") else None + if isinstance(raw_controls, str): + raw_controls = _parse_json_result(raw_controls) + otp_controls = classify_otp_controls([LoginControl.from_dict(r) for r in (raw_controls or []) if isinstance(r, dict)]) + if not otp_controls: + return json.dumps({"success": False, "error_type": "no_code_field", + "error": ("No one-time-code field on the current page. If the site wants a passkey, hardware key or " + "an approval tap in an app, tell the user to complete it on their device and wait for the page to move on.")}) + + code: Optional[str] = None + source = "user" + backend = backend_for_handle(handle) if handle else None + if backend is not None: + try: + code = backend.resolve_otp(handle) + except Exception: + code = None + if code: + source = backend.name + if not code: + prompt = get_code_prompt_callback() + if prompt is None or not can_prompt_here(): + return json.dumps({"success": False, "error_type": "prompt_unavailable", + "error": (f"{site} asks for a one-time code and this session cannot ask the user (headless/cron/API). " + "Save an authenticator key for this login so codes can be generated automatically.")}) + code = (prompt(site, "") or "").strip().replace(" ", "").replace("-", "") + if not code: + return json.dumps({"success": False, "error_type": "code_declined", + "error": "The user did not enter a code. Do not ask again this turn."}) + + register_vault_redaction_value(code) + fills = build_otp_fills(otp_controls, code) + result = _eval_js_secret(effective_task_id, build_fill_js(fills, expected_origin=origin, nonce=nonce)) + del code + if not result.get("success"): + return json.dumps({"success": False, "error": str(result.get("error") or "fill failed")[:200]}) + parsed = _parse_json_result(result.get("result")) + if isinstance(parsed, str): + parsed = _parse_json_result(parsed) + if isinstance(parsed, dict) and parsed.get("refused") == "origin_changed": + return json.dumps({"success": False, "error_type": "origin_changed", "error": "The page navigated before the code could be entered. Nothing was written."}) + filled = int(parsed.get("filled", 0)) if isinstance(parsed, dict) else 0 + return json.dumps({"success": bool(filled), "filled_fields": filled, "origin": origin, "source": source, + "next": "Submit the form (many sites auto-submit when the last digit lands)."}) + + def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str: """Fill the current page's password field from a vault handle. @@ -471,6 +542,9 @@ def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str: out = {"success": bool(filled), "filled_fields": int(filled), "backend": backend.name, "kind": meta.kind, "origin": meta.origin} + if meta.kind == "login": + out["next"] = ("Submit. If the site then asks for a verification code, call browser_vault_enter_code with this handle" + + (" (a code will be generated automatically)." if meta.has_otp else ".")) if meta.kind != "login": out["fields"] = sorted(f["token"] for f in fills) # which controls were targeted, never the values return json.dumps(out) @@ -568,6 +642,28 @@ BROWSER_VAULT_SAVE_LOGIN_SCHEMA = { } +BROWSER_VAULT_ENTER_CODE_SCHEMA = { + "name": "browser_vault_enter_code", + "description": ( + "The page asks for a one-time / verification / 2FA code after the password: call this. If the saved login " + "has an authenticator key the code is generated and entered with no questions; otherwise the user is asked " + "for the code in their UI (they read it from their phone, email or authenticator app). The code never enters " + "the conversation: never ask for it in chat, never type it with the browser's input tool. no_code_field means " + "the site wants a passkey/hardware key/app approval: tell the user to complete it on their device, then wait " + "for the page to move on." + ), + "parameters": { + "type": "object", + "properties": {"handle": {"type": "string", "description": "The login handle you just filled (lets Hermes generate the code when an authenticator key is saved)."}}, + "required": [], + }, +} + + +def _handle_vault_enter_code(args: Dict[str, Any], **kwargs) -> str: + return browser_vault_enter_code(handle=str(args.get("handle") or ""), task_id=kwargs.get("task_id")) + + def _handle_vault_save_login(args: Dict[str, Any], **kwargs) -> str: return browser_vault_save_login(label=str(args.get("label") or ""), task_id=kwargs.get("task_id")) @@ -617,6 +713,15 @@ registry.register( emoji="🔐", ) +registry.register( + name="browser_vault_enter_code", + toolset="browser", + schema=BROWSER_VAULT_ENTER_CODE_SCHEMA, + handler=_handle_vault_enter_code, + check_fn=_check_vault_available, + emoji="🔐", +) + registry.register( name="browser_vault_fill", toolset="browser", diff --git a/tools/thread_context.py b/tools/thread_context.py index 06e51e61b5..293a4e9167 100644 --- a/tools/thread_context.py +++ b/tools/thread_context.py @@ -29,7 +29,8 @@ def _callback_api(): return ((tt._get_approval_callback, tt.set_approval_callback), (tt._get_sudo_password_callback, tt.set_sudo_password_callback), (vault_unlock.get_unlock_prompt_callback, vault_unlock.set_unlock_prompt_callback), - (vault_unlock.get_save_login_prompt_callback, vault_unlock.set_save_login_prompt_callback)) + (vault_unlock.get_save_login_prompt_callback, vault_unlock.set_save_login_prompt_callback), + (vault_unlock.get_code_prompt_callback, vault_unlock.set_code_prompt_callback)) def propagate_context_to_thread(target: Callable) -> Callable: diff --git a/toolsets.py b/toolsets.py index 26ff03daf6..3da2993c07 100644 --- a/toolsets.py +++ b/toolsets.py @@ -18,7 +18,7 @@ _HERMES_CORE_TOOLS = [ "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", "browser_dialog", - "browser_vault_list", "browser_vault_unlock", "browser_vault_fill", "browser_vault_save_login", # ride with the browser + "browser_vault_list", "browser_vault_unlock", "browser_vault_fill", "browser_vault_save_login", "browser_vault_enter_code", # ride with the browser "browser_exec", # replaces the other browser tools when browser.backend is "browser-use" "text_to_speech", "todo_list", "memory", diff --git a/tui_gateway/agent_callbacks.py b/tui_gateway/agent_callbacks.py index 03f729889b..bea1f2ab86 100644 --- a/tui_gateway/agent_callbacks.py +++ b/tui_gateway/agent_callbacks.py @@ -173,8 +173,8 @@ def _wire_callbacks(sid: str): set_secret_capture_callback(secret_cb) # External password-manager unlock: the renderer shows a masked master-password card; the # answer is consumed by the manager CLI on stdin and only a session token stays in memory. - from agent.vault_backends.unlock import (set_current_session_id, set_save_login_prompt_callback, - set_unlock_prompt_callback) + from agent.vault_backends.unlock import (set_code_prompt_callback, set_current_session_id, + set_save_login_prompt_callback, set_unlock_prompt_callback) set_current_session_id(sid) # an unlock made on this turn belongs to this session (released with it) set_unlock_prompt_callback(lambda backend, display_name: _block( "vault.unlock.request", sid, {"backend": backend, "display_name": display_name}, timeout=120)) @@ -189,6 +189,8 @@ def _wire_callbacks(sid: str): return data if isinstance(data, dict) and data.get("password") else None set_save_login_prompt_callback(save_login_cb) + set_code_prompt_callback(lambda site, hint: _block( + "vault.code.request", sid, {"site": site, "hint": hint}, timeout=180)) def _available_personalities(cfg: dict | None = None) -> dict: diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index 8ecea3b631..eb1490e754 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -1098,7 +1098,7 @@ _LATE_RESPOND_KEYS = { "terminal.read.respond": "text", "preview.read.respond": "text", "preview.act.respond": "text", "window.read.respond": "text", "tour.respond": "text", "mcp.setup.respond": "result", "sudo.respond": "password", "secret.respond": "value", "vault.unlock.respond": "password", - "vault.save_login.respond": "login"} + "vault.save_login.respond": "login", "vault.code.respond": "code"} for _name, _key in _LATE_RESPOND_KEYS.items(): method(_name)(lambda rid, params, _k=_key: _respond(rid, params, _k, allow_expired=True)) del _name, _key diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7203e95ab0..0abc099681 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1247,7 +1247,7 @@ def _enable_gateway_prompts() -> None: # Blocking bridges whose `*.respond` tolerates a late reply (allow_expired=True): on timeout the tool # returns empty, but a slow renderer could still answer and hit a raw 4009 — `.expire` tears the card down. _EXPIRING_REQUESTS = frozenset({ - "secret.request", "sudo.request", "vault.unlock.request", "vault.save_login.request", "clarify.request", + "secret.request", "sudo.request", "vault.unlock.request", "vault.save_login.request", "vault.code.request", "clarify.request", "terminal.read.request", "preview.read.request", "preview.act.request", "window.read.request", "mcp.setup.request", "tour.request", diff --git a/website/docs/user-guide/features/credential-vault.md b/website/docs/user-guide/features/credential-vault.md index 68e1eb7b63..d003990d1b 100644 --- a/website/docs/user-guide/features/credential-vault.md +++ b/website/docs/user-guide/features/credential-vault.md @@ -34,6 +34,22 @@ fills the password through Hermes. The tool result it sees is `{filled_fields: 1, origin: "https://github.com"}`; the password is also registered with the redactor so a later page read cannot echo it back. +## Two-factor codes + +Sites that ask for a code after the password are handled the same way: + +- **Authenticator key saved with the login** (the "setup key" or `otpauth://` + link a site shows when you enable 2FA; 1Password and Bitwarden items that + hold a TOTP seed count too): Hermes generates the current code and enters + it. Nobody is asked. Add the key in **Settings → Passwords & Logins → Add** + or `hermes vault add`; the item shows a *2FA auto* badge. +- **Code sent to your phone or email**: a small prompt appears in your + surface ("Verification code for github.com"), you type the code, Hermes + enters it into the page. The code never enters the conversation either. +- **Passkeys, hardware keys, app approvals** ("tap Approve in Duo"): nothing + to type. The agent tells you to complete it on your device and waits for + the page to move on. + ## Already using 1Password or Bitwarden? Nothing to enable. If the `op` or `bw` command-line tool is installed and signed