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