feat(vault): two-factor codes — automatic from a saved authenticator key, otherwise asked for in the user's UI

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).
This commit is contained in:
Teknium
2026-09-10 11:10:47 -07:00
parent 77e55b4d1f
commit d9ca9c974d
36 changed files with 727 additions and 34 deletions
+5
View File
@@ -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."""
+8
View File
@@ -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 <id>` 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
+5
View File
@@ -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()}
+8
View File
@@ -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:
+13
View File
@@ -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
+35
View File
@@ -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 }));
+40 -2
View File
@@ -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")),
)
@@ -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).
@@ -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<string, string> {
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() {
<span className="flex items-center gap-2">
<span className="truncate">{item.label}</span>
<Pill tone={item.kind === 'login' ? 'primary' : 'muted'}>{kindLabel(item.kind)}</Pill>
{item.has_otp && <Pill tone="muted">{v.twoFactorBadge}</Pill>}
</span>
}
/>
@@ -645,6 +649,17 @@ export function VaultSettings() {
value={form.password}
/>
</Field>
<Field htmlFor="vault-otp" label={v.otpField} optional optionalLabel={v.optional}>
<Input
autoComplete="off"
id="vault-otp"
onChange={e => setForm(f => ({ ...f, otpSecret: e.target.value }))}
placeholder={v.otpPlaceholder}
type="password"
value={form.otpSecret}
/>
<p className="text-xs text-muted-foreground">{v.otpHint}</p>
</Field>
</>
)}
@@ -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 (
<Dialog onOpenChange={open => !open && !submitting && void send('')} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle icon={ShieldLock}>{copy.vaultCodeTitle(request.site)}</DialogTitle>
<DialogDescription>{copy.vaultCodeDesc(request.site)}</DialogDescription>
</DialogHeader>
<form
className="grid gap-3"
onSubmit={event => {
event.preventDefault()
if (trimmed) {
void send(trimmed)
}
}}
>
<Field htmlFor="vault-code" label={copy.vaultCodeLabel}>
<Input
autoComplete="one-time-code"
autoFocus
disabled={submitting}
id="vault-code"
inputMode="numeric"
onChange={event => setCode(event.target.value)}
placeholder="123 456"
value={code}
/>
</Field>
<p className="text-xs text-muted-foreground">{copy.vaultCodeFootnote}</p>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
{copy.vaultCodeSkip}
</Button>
<Button disabled={submitting || !trimmed} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.vaultCodeConfirm}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
/** 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 }) {
<SecretDialog sessionId={sessionId} />
<VaultUnlockDialog sessionId={sessionId} />
<VaultSaveLoginDialog sessionId={sessionId} />
<VaultCodeDialog sessionId={sessionId} />
</>
)
}
@@ -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<Record<string, unknown>>()),
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(<PromptOverlays sessionId="session-a" />)
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(<PromptOverlays sessionId="session-a" />)
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())
})
+13 -1
View File
@@ -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: 'فشلت قراءة الصوت',
+13 -1
View File
@@ -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: {
+13 -1
View File
@@ -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: {
+11
View File
@@ -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
+13 -1
View File
@@ -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: {
+13 -1
View File
@@ -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: {
+2 -1
View File
@@ -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
+2
View File
@@ -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',
+32 -9
View File
@@ -129,6 +129,16 @@ export interface VaultSaveLoginRequest extends KeyedPrompt {
const vaultSave = keyedPromptStore<VaultSaveLoginRequest>()
// Second-factor code for the page the agent is on. Resolved via vault.code.respond
// {request_id, code}; "" skips.
export interface VaultCodeRequest extends KeyedPrompt {
site: string
hint: string
requestId: string
}
const vaultCode = keyedPromptStore<VaultCodeRequest>()
// Inline approval anchors, keyed by session: a tile's inline bar mounting must
// not suppress the PRIMARY session's floating fallback (and vice versa).
const $approvalInlineAnchors = atom<Record<string, number>>({})
@@ -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)
}
+5 -4
View File
@@ -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.
+3 -1
View File
@@ -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.
+3 -1
View File
@@ -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)
+22
View File
@@ -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:
+12 -2
View File
@@ -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):
+3
View File
@@ -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
+3 -2
View File
@@ -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]]:
+1
View File
@@ -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")]
+79
View File
@@ -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"]
+105
View File
@@ -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",
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -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",
+4 -2
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
@@ -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